msg_tool\scripts\artemis/
txt.rs

1use crate::scripts::base::*;
2use crate::types::*;
3use crate::utils::encoding::*;
4use anyhow::{Result, anyhow};
5use std::io::Write;
6
7#[derive(Debug)]
8/// Builder for general Artemis TXT scripts.
9pub struct ArtemisTxtBuilder {}
10
11impl ArtemisTxtBuilder {
12    /// Creates a new builder instance.
13    pub const fn new() -> Self {
14        Self {}
15    }
16}
17
18impl ScriptBuilder for ArtemisTxtBuilder {
19    fn default_encoding(&self) -> Encoding {
20        Encoding::Cp932
21    }
22
23    fn build_script(
24        &self,
25        buf: Vec<u8>,
26        _filename: &str,
27        encoding: Encoding,
28        _archive_encoding: Encoding,
29        _config: &ExtraConfig,
30        _archive: Option<&Box<dyn Script>>,
31    ) -> Result<Box<dyn Script>> {
32        Ok(Box::new(ArtemisTxtScript::new(buf, encoding)?))
33    }
34
35    fn extensions(&self) -> &'static [&'static str] {
36        &["txt"]
37    }
38
39    fn script_type(&self) -> &'static ScriptType {
40        &ScriptType::ArtemisTxt
41    }
42}
43
44#[derive(Debug, Clone)]
45struct MessageRef {
46    line_index: usize,
47    speaker: Option<String>,
48    speaker_line_index: Option<usize>,
49}
50
51#[derive(Debug)]
52pub struct ArtemisTxtScript {
53    lines: Vec<String>,
54    message_map: Vec<MessageRef>,
55    use_crlf: bool,
56    trailing_newline: bool,
57}
58
59impl ArtemisTxtScript {
60    fn new(buf: Vec<u8>, encoding: Encoding) -> Result<Self> {
61        let script = decode_to_string(encoding, &buf, true)?;
62        let use_crlf = script.contains("\r\n");
63        let trailing_newline = script.ends_with('\n');
64        let mut lines: Vec<String> = script
65            .split('\n')
66            .map(|line| {
67                if use_crlf {
68                    line.strip_suffix('\r').unwrap_or(line).to_string()
69                } else {
70                    line.to_string()
71                }
72            })
73            .collect();
74        if trailing_newline {
75            // split('\n') keeps a trailing empty entry we do not want to lose
76            if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
77                lines.pop();
78            }
79        }
80        let message_map = Self::collect_messages(&lines);
81        Ok(Self {
82            lines,
83            message_map,
84            use_crlf,
85            trailing_newline,
86        })
87    }
88
89    fn collect_messages(lines: &[String]) -> Vec<MessageRef> {
90        let mut refs = Vec::new();
91        let mut current_speaker: Option<String> = None;
92        let mut current_speaker_line: Option<usize> = None;
93        for (idx, line) in lines.iter().enumerate() {
94            let trimmed = line.trim();
95            if trimmed.is_empty() {
96                continue;
97            }
98            if trimmed.starts_with("//") {
99                continue;
100            }
101            if trimmed.starts_with('*') {
102                continue;
103            }
104            if trimmed.starts_with('[') {
105                continue;
106            }
107            if trimmed.starts_with('#') {
108                match Self::parse_hash_speaker(trimmed) {
109                    Some(name) => {
110                        current_speaker = Some(name);
111                        current_speaker_line = Some(idx);
112                    }
113                    None => {
114                        current_speaker = None;
115                        current_speaker_line = None;
116                    }
117                }
118                continue;
119            }
120
121            let speaker = if Self::is_dialogue_line(trimmed) {
122                current_speaker.clone()
123            } else {
124                None
125            };
126            let speaker_line_index = if speaker.is_some() {
127                current_speaker_line
128            } else {
129                None
130            };
131            refs.push(MessageRef {
132                line_index: idx,
133                speaker,
134                speaker_line_index,
135            });
136        }
137        refs
138    }
139
140    fn parse_hash_speaker(line: &str) -> Option<String> {
141        let content = line.trim_start_matches('#').trim();
142        if content.is_empty() {
143            return None;
144        }
145        let mut parts = content.split_whitespace();
146        let token = parts.next()?;
147        let upper = token.to_ascii_uppercase();
148        if upper.starts_with("BGM")
149            || upper.starts_with("SE")
150            || upper.starts_with("FGA")
151            || upper.starts_with("FG")
152        {
153            return None;
154        }
155        if token == "服装" {
156            return None;
157        }
158        Some(token.to_string())
159    }
160
161    fn is_dialogue_line(line: &str) -> bool {
162        match line.chars().next() {
163            Some('"') | Some('“') | Some('〝') | Some('(') | Some('(') | Some('「')
164            | Some('『') => true,
165            _ => false,
166        }
167    }
168
169    fn join_lines(&self, lines: &[String]) -> String {
170        let newline = if self.use_crlf { "\r\n" } else { "\n" };
171        let mut combined = lines.join(newline);
172        if self.trailing_newline {
173            combined.push_str(newline);
174        }
175        combined
176    }
177
178    fn set_speaker_line(line: &str, name: &str) -> String {
179        if let Some(hash_pos) = line.find('#') {
180            let after_hash = &line[hash_pos + 1..];
181            let start_rel = after_hash
182                .char_indices()
183                .find(|(_, ch)| !ch.is_whitespace())
184                .map(|(offset, _)| offset);
185            let start_rel = match start_rel {
186                Some(offset) => offset,
187                None => {
188                    let mut result = String::with_capacity(line.len() + name.len());
189                    result.push_str(line);
190                    result.push_str(name);
191                    return result;
192                }
193            };
194            let start = hash_pos + 1 + start_rel;
195            let tail = &after_hash[start_rel..];
196            let mut name_len = 0;
197            let mut end_rel = tail.len();
198            for (offset, ch) in tail.char_indices() {
199                if ch.is_whitespace() {
200                    end_rel = offset;
201                    break;
202                }
203                name_len = offset + ch.len_utf8();
204            }
205            let end = if tail.is_empty() {
206                start
207            } else if end_rel == tail.len() {
208                start + name_len
209            } else {
210                start + end_rel
211            };
212            let mut result = String::with_capacity(line.len() + name.len());
213            result.push_str(&line[..start]);
214            result.push_str(name);
215            result.push_str(&line[end..]);
216            return result;
217        }
218        format!("#{}", name)
219    }
220}
221
222impl Script for ArtemisTxtScript {
223    fn default_output_script_type(&self) -> OutputScriptType {
224        OutputScriptType::Json
225    }
226
227    fn default_format_type(&self) -> FormatOptions {
228        FormatOptions::None
229    }
230
231    fn extract_messages(&self) -> Result<Vec<Message>> {
232        let mut messages = Vec::with_capacity(self.message_map.len());
233        for entry in &self.message_map {
234            let text = self
235                .lines
236                .get(entry.line_index)
237                .cloned()
238                .unwrap_or_default();
239            messages.push(Message {
240                name: entry.speaker.clone(),
241                message: text,
242            });
243        }
244        Ok(messages)
245    }
246
247    fn import_messages<'a>(
248        &'a self,
249        messages: Vec<Message>,
250        mut file: Box<dyn WriteSeek + 'a>,
251        _filename: &str,
252        encoding: Encoding,
253        replacement: Option<&'a ReplacementTable>,
254    ) -> Result<()> {
255        if messages.len() != self.message_map.len() {
256            return Err(anyhow!(
257                "Message count mismatch: expected {}, got {}",
258                self.message_map.len(),
259                messages.len()
260            ));
261        }
262        let mut output_lines = self.lines.clone();
263        for (entry, message) in self.message_map.iter().zip(messages.iter()) {
264            let mut text = message.message.clone();
265            if let Some(repl) = replacement {
266                for (from, to) in &repl.map {
267                    text = text.replace(from, to);
268                }
269            }
270            if let Some(line) = output_lines.get_mut(entry.line_index) {
271                *line = text;
272            }
273            if let (Some(speaker_line_index), Some(name)) =
274                (entry.speaker_line_index, message.name.as_ref())
275            {
276                let mut patched_name = name.clone();
277                if let Some(repl) = replacement {
278                    for (from, to) in &repl.map {
279                        patched_name = patched_name.replace(from, to);
280                    }
281                }
282                if let Some(line) = output_lines.get_mut(speaker_line_index) {
283                    *line = Self::set_speaker_line(line, &patched_name);
284                } else {
285                    return Err(anyhow!(
286                        "Speaker line index out of bounds: {}",
287                        speaker_line_index
288                    ));
289                }
290            }
291        }
292        let combined = self.join_lines(&output_lines);
293        let encoded = encode_string(encoding, &combined, true)?;
294        file.write_all(&encoded)?;
295        Ok(())
296    }
297}